# frozen_string_literal: true

module Gitlab
  module Ci
    module Reports
      module Security
        class VulnerabilityReportsComparer
          include Gitlab::Utils::StrongMemoize

          attr_reader :base_report, :head_report, :project, :signatures_enabled

          ACCEPTABLE_REPORT_AGE = 1.week
          MAX_FINDINGS_COUNT = 25
          VULNERABILITY_FILTER_METRIC_KEY = :vulnerability_report_branch_comparison

          def initialize(project, base_report, head_report)
            @base_report = base_report
            @head_report = head_report
            @project = project

            @signatures_enabled = project.licensed_feature_available?(:vulnerability_finding_signatures)

            return unless @signatures_enabled

            @added_findings = []
            @fixed_findings = []
            calculate_changes
          end

          def base_report_created_at
            @base_report.created_at
          end

          def head_report_created_at
            @head_report.created_at
          end

          def base_report_out_of_date
            return false unless @base_report.created_at

            ACCEPTABLE_REPORT_AGE.ago > @base_report.created_at
          end

          # rubocop:disable CodeReuse/ActiveRecord
          def undismissed_on_default_branch(findings, limit)
            uuids = findings.map(&:uuid)

            query = Vulnerability
              .present_on_default_branch.with_findings_by_uuid_and_state(uuids, :dismissed)
              .limit(limit)

            dismissed_uuids = ::Gitlab::Metrics.measure(VULNERABILITY_FILTER_METRIC_KEY) do
              query.pluck(:uuid)
            end.to_set

            findings.reject { |f| dismissed_uuids.include?(f.uuid) }
          end

          def process_findings(findings)
            unchecked_findings = findings.each_slice(MAX_FINDINGS_COUNT).to_a
            undismissed_findings = []
            limit = MAX_FINDINGS_COUNT

            while unchecked_findings.any? && limit > 0
              undismissed_findings += undismissed_on_default_branch(unchecked_findings.shift, limit)
              limit -= undismissed_findings.size
            end

            undismissed_findings
              .tap { |findings| override_uuids_for!(findings) }
          end
          # rubocop:enable CodeReuse/ActiveRecord

          def added
            process_findings(all_added_findings)
          end
          strong_memoize_attr :added

          def fixed
            process_findings(all_fixed_findings)
          end
          strong_memoize_attr :fixed

          private

          def calculate_changes
            # This is a deconstructed version of the eql? method on
            # Ci::Reports::Security::Finding. It:
            #
            # * precomputes for the head_findings (using FindingMatcher):
            #   * sets of signature shas grouped by priority
            #   * mappings of signature shas to the head finding object
            #
            # These are then used when iterating the base findings to perform
            # fast(er) prioritized, signature-based comparisons between each base finding
            # and the head findings.
            #
            # Both the head_findings and base_findings arrays are iterated once

            base_findings = base_report.findings
            head_findings = head_report.findings

            matcher = FindingMatcher.new(head_findings)

            base_findings.each do |base_finding|
              next if base_finding.requires_manual_resolution?

              matched_head_finding = matcher.find_and_remove_match!(base_finding)

              @fixed_findings << base_finding if matched_head_finding.nil?
            end

            @added_findings = matcher.unmatched_head_findings.values
          end

          def all_added_findings
            if @signatures_enabled
              @added_findings
            else
              head_report.findings - base_report.findings
            end
          end

          def all_fixed_findings
            if @signatures_enabled
              @fixed_findings
            else
              base_report.findings - head_report.findings
            end
          end

          def override_uuids_for!(findings)
            return unless override_uuids?

            UUIDOverrider.execute(project, findings)
          end

          def override_uuids?
            signatures_enabled && sast_report?
          end

          def sast_report?
            head_report.findings.first&.report_type.to_s == 'sast'
          end
        end

        # Takes a project and a list of non-persisted findings and tries to match those
        # given findings with the already existing vulnerability findings of the given project.
        # If the non-persisted finding matches with an existing vulnerability finding, the UUID
        # of the finding will be changed by the existing vulnerability finding's
        class UUIDOverrider
          def self.execute(project, findings)
            new(project, findings).execute
          end

          def initialize(project, findings)
            @project = project
            @findings = findings
          end

          def execute
            findings.each { |finding| override_uuid_for!(finding) }
          end

          private

          attr_reader :project, :findings

          def override_uuid_for!(finding)
            persisted_finding = persisted_vulnerability_finding_for(finding)

            finding.uuid = persisted_finding.uuid if persisted_finding && known_uuids.add?(persisted_finding.uuid)
          end

          def persisted_vulnerability_finding_for(finding)
            persisted_finding_by_signature(finding) || persisted_finding_by_location(finding)
          end

          def persisted_finding_by_signature(finding)
            shas = finding.signatures.sort_by(&:priority).map(&:signature_sha)

            persisted_signatures.values_at(*shas).compact.map(&:finding).find do |persisted_finding|
              compare_with_persisted_finding(persisted_finding, finding)
            end
          end

          def persisted_finding_by_location(finding)
            return unless finding.signatures.present?

            persisted_findings_by_location[finding.location_fingerprint].to_a.find do |persisted_finding|
              compare_with_persisted_finding(persisted_finding, finding)
            end
          end

          def compare_with_persisted_finding(persisted_finding, finding)
            persisted_finding.primary_identifier&.fingerprint == finding.identifiers.first.fingerprint &&
              persisted_finding.scanner == scanners[finding.scanner.external_id]
          end

          def persisted_signatures
            @persisted_signatures ||= ::Vulnerabilities::FindingSignature.by_signature_sha(finding_signature_shas)
              .by_project(project)
              .eager_load_comparison_entities
              .index_by(&:signature_sha)
          end

          def finding_signature_shas
            @finding_signature_shas ||= findings.flat_map(&:signatures).map(&:signature_sha)
          end

          def persisted_findings_by_location
            @persisted_findings_by_location ||= project.vulnerability_findings
                                                      .sast
                                                      .by_location_fingerprints(location_fingerprints)
                                                      .eager_load_comparison_entities
                                                      .group_by(&:location_fingerprint)
          end

          def location_fingerprints
            findings.map(&:location_fingerprint)
          end

          def known_uuids
            @known_uuids ||= findings.map(&:uuid).to_set
          end

          def scanners
            @scanners ||= project.vulnerability_scanners.index_by(&:external_id)
          end
        end

        class FindingMatcher
          attr_reader :unmatched_head_findings, :head_findings

          include Gitlab::Utils::StrongMemoize

          def initialize(head_findings)
            @head_findings = head_findings
            @unmatched_head_findings = @head_findings.index_by(&:object_id)
          end

          def find_and_remove_match!(base_finding)
            matched_head_finding = find_matched_head_finding_for(base_finding)

            # no signatures matched, so check the normal uuids of the base and head findings
            # for a match
            matched_head_finding = head_signatures_shas[base_finding.uuid] if matched_head_finding.nil?

            @unmatched_head_findings.delete(matched_head_finding.object_id) unless matched_head_finding.nil?

            matched_head_finding
          end

          private

          def find_matched_head_finding_for(base_finding)
            base_signature = sorted_signatures_for(base_finding).find do |signature|
              # at this point a head_finding exists that has a signature with a
              # matching priority, and a matching sha --> lookup the actual finding
              # object from head_signatures_shas
              head_signatures_shas[signature.signature_sha].eql?(base_finding)
            end

            base_signature.present? ? head_signatures_shas[base_signature.signature_sha] : nil
          end

          def sorted_signatures_for(base_finding)
            base_finding.signatures.select { |signature| head_finding_signature?(signature) }
                                   .sort_by { |sig| -sig.priority }
          end

          def head_finding_signature?(signature)
            head_signatures_priorities[signature.priority].include?(signature.signature_sha)
          end

          def head_signatures_priorities
            signatures_priorities = Hash.new { |hash, key| hash[key] = Set.new }

            head_findings.each_with_object(signatures_priorities) do |head_finding, memo|
              head_finding.signatures.each do |signature|
                memo[signature.priority].add(signature.signature_sha)
              end
            end
          end
          strong_memoize_attr :head_signatures_priorities

          def head_signatures_shas
            head_findings.each_with_object({}) do |head_finding, memo|
              head_finding.signatures.each do |signature|
                memo[signature.signature_sha] = head_finding
              end
              # for the final uuid check when no signatures have matched
              memo[head_finding.uuid] = head_finding
            end
          end
          strong_memoize_attr :head_signatures_shas
        end
      end
    end
  end
end
